summaryrefslogtreecommitdiff
path: root/src/pages/blog/read/[slug].astro
diff options
context:
space:
mode:
Diffstat (limited to 'src/pages/blog/read/[slug].astro')
-rw-r--r--src/pages/blog/read/[slug].astro576
1 files changed, 576 insertions, 0 deletions
diff --git a/src/pages/blog/read/[slug].astro b/src/pages/blog/read/[slug].astro
new file mode 100644
index 0000000..263b31d
--- /dev/null
+++ b/src/pages/blog/read/[slug].astro
@@ -0,0 +1,576 @@
+---
+import { type CollectionEntry, getCollection } from "astro:content";
+import { render } from "astro:content";
+import Translations from "@components/Translations.astro";
+import KeywordsList from "@components/organisms/KeywordsList.astro";
+import Citations from "@components/Citations.astro";
+import Signature from "@components/templates/signature/Signature.astro";
+import CopyrightNotice from "@components/templates/CopyrightNotice.astro";
+import { verifier as verifierPrototype } from "@lib/pgp/verify";
+import {
+ fromPosts,
+ getSigners,
+ getSignersIDs,
+ isTranslation,
+ licenseNotice,
+ licenseURL,
+} from "@lib/collection/helpers";
+import { defined, get, transform } from "@utils/anonymous";
+import Authors from "@components/templates/Authors.astro";
+import Base from "@layouts/Base.astro";
+import type {
+ GetStaticPaths,
+ InferGetStaticParamsType,
+ InferGetStaticPropsType,
+} from "astro";
+import DateTime from "@components/organisms/Date.astro";
+import { getUserIDsFromKey } from "@lib/pgp/user";
+import type { PublicKey, UserIDPacket } from "openpgp";
+import type { BlogPosting, Person } from "@lib/collection/types";
+import {
+ type MicroEntry,
+ Original,
+ type OriginalEntry,
+ Translation,
+} from "@lib/collection/schemas";
+import { getEntry } from "astro:content";
+import { getEntries } from "astro:content";
+import readingTime from "reading-time";
+import { fileCreationCommitDate } from "@lib/git/log";
+
+export const getStaticPaths = (async (): Promise<
+ {
+ params: { slug: string };
+ props: CollectionEntry<"blog">;
+ }[]
+> => {
+ const posts = await getCollection("blog");
+ return posts.map((post) => ({
+ params: { slug: post.id },
+ props: post,
+ }));
+}) satisfies GetStaticPaths;
+
+type Params = InferGetStaticParamsType<typeof getStaticPaths>;
+type Props = InferGetStaticPropsType<typeof getStaticPaths>;
+
+let post: Props | undefined = Astro.props;
+
+const verifier = await verifierPrototype.then((x) => x.clone());
+
+const signers: Map<
+ string,
+ {
+ signer: Awaited<ReturnType<typeof getSigners>>[number];
+ users: UserIDPacket[];
+ key: PublicKey;
+ }
+> = new Map();
+// Add signers public keys to keyring
+for (const signer of await getSigners(post)) {
+ const { data } = signer.entity;
+ const key = await verifier.addKeyFromArmor(data.publickey.armor);
+ signers.set(key.getFingerprint(), {
+ signer,
+ users: getUserIDsFromKey(undefined, key),
+ key,
+ });
+}
+
+const createPerson = (
+ { signer, users }: typeof signers extends Map<any, infer V> ? V : never,
+): Person | undefined => ({
+ "@type": "Person",
+ "@id": signer.entity.id, // TODO: URL
+ name: users.find(({ name }) => name.length > 0)?.name,
+ url: signer.entity.data.websites,
+ email: users.find(({ email }) => email.length > 0)?.email,
+});
+
+const signersValues = Array.from(signers.values());
+const author: Person | undefined = transform(
+ signersValues.find(({ signer }) => signer.role === "author"),
+ (x) => x !== undefined ? createPerson(x) : undefined,
+);
+const coauthors: Person[] = signersValues.filter(({ signer }) =>
+ signer.role === "co-author"
+).map(createPerson).filter(defined);
+const translators: Person[] = signersValues.filter(({ signer }) =>
+ signer.role === "translator"
+).map(createPerson).filter(defined);
+
+const { id, data, rendered, body, filePath } = post;
+
+const path = new URL(`file://${Deno.cwd()}/${filePath}`);
+const verification = post.filePath !== undefined
+ ? await verifier.verify([path])
+ : undefined;
+
+const commit = await verification?.commit;
+
+const { title, lang, dateCreated, dateUpdated, license } = data;
+
+let original: OriginalEntry | MicroEntry;
+try {
+ const { translationOf } = Translation.parse(post);
+ const maybeOriginal = await getEntry(translationOf) as
+ | OriginalEntry
+ | MicroEntry
+ | undefined;
+
+ if (maybeOriginal === undefined) {
+ throw new Error(`Original post not found for ${id}`);
+ }
+
+ original = maybeOriginal;
+
+ const { author: [originalAuthors], "co-author": originalCoauthors } =
+ getSignersIDs(original);
+ const originalAuthor = originalAuthors?.[0];
+
+ if (
+ (author !== undefined &&
+ author["@id"] !== originalAuthor) ||
+ !new Set(coauthors).isSubsetOf(new Set(originalCoauthors))
+ ) {
+ throw new Error(
+ `Post ${id} has mismatched (co-)authors from original post ${original.id}`,
+ );
+ }
+
+ for (const { "@id": t } of translators) {
+ if (
+ originalAuthor === t || originalCoauthors.includes(t)
+ ) {
+ throw new Error(
+ `Translator ${t} in ${id} is already a (co-)author in original post`,
+ );
+ }
+ }
+} catch {
+ original = post as OriginalEntry | MicroEntry;
+ if (signersValues.some(({ signer }) => signer.role === "translator")) {
+ throw new Error(
+ `Post ${id} is not a translation but has translators defined`,
+ );
+ }
+}
+
+const translationsSet = await fromPosts(
+ isTranslation,
+ (x) =>
+ new Set(
+ x.filter(({ data }) => data.translationOf.id === original.id).map(
+ get("id"),
+ ),
+ ),
+);
+translationsSet.add(original.id);
+
+const translations = await getEntries(
+ Array.from(translationsSet).map((id) => ({
+ collection: original.collection,
+ id,
+ })),
+);
+
+const reading = body ? readingTime(body, {}) : undefined;
+const minutes = reading === undefined
+ ? undefined
+ : Math.ceil(reading.minutes);
+const estimative = minutes === undefined
+ ? undefined
+ : new Intl.DurationFormat(lang, {
+ style: "long",
+ }).format({ hours: Math.floor(minutes / 60), minutes: minutes % 60 });
+const duration = minutes === undefined
+ ? undefined
+ : `PT${Math.floor(minutes / 60) > 0 ? Math.floor(minutes / 60) + "H" : ""}${
+ minutes % 60 > 0 ? minutes % 60 + "M" : ""
+ }`;
+
+const linkedData: BlogPosting & { "@context": "https://schema.org" } = {
+ "@context": "https://schema.org",
+ "@type": "BlogPosting",
+ "@id": Astro.url.href,
+ url: Astro.url.href,
+ headline: title,
+ name: title,
+ abstract: "description" in data ? data.description : undefined,
+ alternativeHeadline: "subtitle" in data ? data.subtitle : undefined,
+ inLanguage: lang,
+ workTranslations: translations.filter((post) =>
+ post.id !== id && post.id !== original.id
+ ).map(({ id, data }) =>
+ ({
+ "@type": "BlogPosting",
+ "@id": new URL(`blog/read/${id}`, Astro.site).href,
+ url: new URL(`blog/read/${id}`, Astro.site).href,
+ headline: data.title,
+ name: data.title,
+ inLanguage: data.lang,
+ dateCreated: data.dateCreated.toISOString(),
+ license: licenseURL(data.license)?.href,
+ translator: data.signers.filter(({ role }) => role === "translator")
+ .map((
+ { entity },
+ ): Person => ({
+ "@type": "Person",
+ "@id": entity.id,
+ })),
+ }) as BlogPosting
+ ),
+ translationOfWork: original.id !== post.id
+ ? {
+ "@type": "BlogPosting",
+ "@id": new URL(`blog/read/${original.id}`, Astro.site).href,
+ url: new URL(`blog/read/${original.id}`, Astro.site).href,
+ headline: original.data.title,
+ name: original.data.title,
+ inLanguage: original.data.lang as string,
+ dateCreated: original.data.dateCreated.toISOString(),
+ license: licenseURL(original.data.license)?.href,
+ } as BlogPosting
+ : undefined,
+ // TODO: version
+ author,
+ contributor: coauthors,
+ translator: translators,
+ dateCreated: dateCreated.toISOString(),
+ dateModified: dateUpdated?.toISOString(),
+ datePublished: await fileCreationCommitDate(path).then((date) =>
+ date?.toISOString()
+ ),
+ timeRequired: duration,
+ wordCount: reading?.words,
+ articleBody: rendered?.html ?? body,
+ text: rendered?.html ?? body,
+ keywords: original.data.keywords,
+ citation: await transform(
+ Original.safeParse(original.data).data,
+ async (o) => {
+ if (o === undefined) return o;
+ const related = await getEntries(o.relatedPosts);
+ return related.map(({ data }): BlogPosting => ({
+ "@type": "BlogPosting",
+ "@id": new URL(`blog/read/${id}`, Astro.site).href,
+ url: new URL(`blog/read/${id}`, Astro.site).href,
+ headline: data.title,
+ name: data.title,
+ inLanguage: data.lang,
+ dateCreated: data.dateCreated.toISOString(),
+ license: licenseURL(data.license)?.href ?? undefined,
+ }));
+ },
+ ), // TODO: citation V.S. mentions
+ mentions: await transform(
+ Original.safeParse(original.data).data,
+ async (o) => {
+ if (o === undefined) return o;
+ const related = await getEntries(o.relatedPosts);
+ return related.map(({ data }): BlogPosting => ({
+ "@type": "BlogPosting",
+ "@id": new URL(`blog/read/${id}`, Astro.site).href,
+ url: new URL(`blog/read/${id}`, Astro.site).href,
+ headline: data.title,
+ name: data.title,
+ inLanguage: data.lang,
+ dateCreated: data.dateCreated.toISOString(),
+ license: licenseURL(data.license)?.href ?? undefined,
+ }));
+ },
+ ), // TODO: citation V.S. mentions
+ copyrightHolder: [author, ...coauthors, ...translators].filter(defined),
+ copyrightNotice: licenseNotice(license, {
+ title,
+ holders: signersValues.map(({ users }) => {
+ const user = users?.[0];
+ if (user === undefined) return undefined;
+
+ const { name, email } = user;
+
+ return (name.length > 0 && email.length > 0)
+ ? { name, email }
+ : undefined;
+ }).filter(defined),
+ years: new Array( // TODO: get years where there were commits
+ (dateUpdated?.getFullYear() ?? dateCreated.getFullYear()) -
+ dateCreated.getFullYear() + 1,
+ ).fill(dateCreated.getFullYear()).map((x, i) => x + i),
+ }, lang),
+ copyrightYear: dateCreated.getFullYear(),
+ creativeWorkStatus: "Published",
+ encodingFormat: "text/html",
+ isAccessibleForFree: true,
+ license: licenseURL(license)?.href ?? undefined,
+ publisher: transform(commit?.committer, (commiter) => {
+ if (commiter === undefined) return undefined;
+
+ const { name, email } = commiter;
+
+ return {
+ "@type": "Person",
+ name,
+ email,
+ };
+ }),
+};
+
+const { Content } = await render(post);
+
+post = undefined;
+---
+
+<Base
+ title={linkedData.headline}
+ description={linkedData.abstract ?? linkedData.headline}
+>
+ <main
+ itemprop="mainContentOfPage"
+ itemscope
+ itemtype="https://schema.org/WebPageElement"
+ >
+ <article
+ itemscope
+ itemtype="http://schema.org/BlogPosting"
+ itemid={Astro.url.href}
+ >
+ <Translations
+ id={linkedData["@id"]}
+ lang={linkedData.inLanguage}
+ workTranslations={linkedData.workTranslations ?? []}
+ translationOfWork={linkedData.translationOfWork}
+ />
+ <hgroup>
+ <h1 itemprop="headline">{linkedData.headline}</h1>
+ {
+ linkedData.alternativeHeadline && (
+ <p itemprop="alternativeHeadline" class="subtitle">
+ {linkedData.alternativeHeadline}
+ </p>
+ )
+ }
+ </hgroup>
+ {
+ linkedData.abstract &&
+ (
+ <section itemprop="abstract">
+ <h2>Resumo</h2>
+ {
+ linkedData.abstract.split(new RegExp("\\s{2,}"))
+ .map((
+ x,
+ ) => <p>{x}</p>)
+ }
+ </section>
+ )
+ }
+ {verification && <Signature {lang} {verification} />}
+ <footer>
+ {
+ verification?.verifications &&
+ (
+ <Authors
+ verifications={verification.verifications}
+ expectedSigners={signers}
+ commitSignerKey={commit?.signature?.key.long}
+ />
+ )
+ }
+ <dl>
+ <dt>Data de criação</dt>
+ <dd>
+ <DateTime
+ date={new Date(linkedData.dateCreated)}
+ locales={linkedData.inLanguage}
+ options={{
+ weekday: "long",
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ timeZoneName: "long",
+ }}
+ itemprop="dateCreated"
+ />
+ </dd>
+ {
+ linkedData.dateModified && (
+ <dt>Última atualização</dt>
+ <dd>
+ <DateTime
+ date={new Date(linkedData.dateModified)}
+ locales={linkedData.inLanguage}
+ options={{
+ weekday: "long",
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ timeZoneName: "long",
+ }}
+ itemprop="dateModified"
+ />
+ </dd>
+ )
+ }
+ {
+ linkedData.locationCreated && (
+ <div
+ itemprop="locationCreated"
+ itemscope
+ itemtype="https://schema.org/Place"
+ >
+ <dt>Local de criação</dt>
+ <dd itemprop="name">{linkedData.locationCreated.name}</dd>
+ </div>
+ )
+ }
+ {
+ linkedData.wordCount && linkedData.timeRequired &&
+ (
+ <>
+ <dt>Tempo de leitura estimado</dt>
+ <dd>
+ <data
+ itemprop="timeRequired"
+ value={linkedData.timeRequired}
+ >~ {estimative}</data>
+ <data itemprop="wordCount" value={linkedData.wordCount}
+ >(<bdi>palavras</bdi>: {linkedData.wordCount})</data>
+ </dd>
+ </>
+ )
+ }
+ </dl>
+ </footer>
+ <div itemprop="articleBody text"><Content /></div>
+ {
+ linkedData.keywords !== undefined &&
+ linkedData.keywords.length > 0 && (
+ <div id="keywords">
+ <KeywordsList keywords={linkedData.keywords} />
+ </div>
+ )
+ }
+ {
+ linkedData.citation !== undefined && (
+ <Citations citations={linkedData.citation} />
+ )
+ }
+ <CopyrightNotice
+ title={linkedData.headline}
+ holders={linkedData.copyrightHolder ?? [{ "@type": "Person" }]}
+ years={new Array( // TODO: get years where there were commits
+ (dateUpdated?.getFullYear() ?? dateCreated.getFullYear()) -
+ dateCreated.getFullYear() + 1,
+ ).fill(dateCreated.getFullYear()).map((x, i) => x + i)}
+ {license}
+ />
+ </article>
+ </main>
+</Base>
+
+<script
+ type="application/ld+json"
+ is:inline
+ set:html={JSON.stringify(linkedData)}
+/>
+
+<script type="module" is:inline>
+ hashchange();
+
+ window.addEventListener("hashchange", hashchange);
+
+ document.addEventListener(
+ "click",
+ function (event) {
+ if (
+ event.target &&
+ event.target instanceof HTMLAnchorElement &&
+ event.target.href === location.href &&
+ location.hash.length > 1
+ ) {
+ requestIdleCallback(function () {
+ if (!event.defaultPrevented) {
+ hashchange();
+ }
+ });
+ }
+ },
+ false,
+ );
+
+ function hashchange() {
+ let hash;
+
+ try {
+ hash = decodeURIComponent(location.hash.slice(1)).toLowerCase();
+ } catch (e) {
+ return;
+ }
+
+ const name = "user-content-" + hash;
+ const target = document.getElementById(name) ||
+ document.getElementsByName(name)[0];
+
+ if (target) {
+ requestIdleCallback(function () {
+ target.scrollIntoView();
+ });
+ }
+ }
+</script>
+
+<style is:inline>
+ section[data-footnotes].footnotes {
+ word-wrap: break-word;
+ }
+</style>
+
+<style>
+ hgroup {
+ text-align: center;
+ }
+
+ .subtitle {
+ font-weight: lighter;
+ }
+
+ #keywords {
+ display: flex;
+ margin-inline: auto;
+ margin-block: calc(var(--size-4) * 1em);
+ }
+
+ [itemprop~="articleBody"] {
+ line-height: 1.4;
+ font-size: 1.2em;
+ text-align: justify;
+
+ & h1,
+ & h2,
+ & h3 {
+ line-height: 1.2;
+ }
+
+ border-block: 1px solid var(--color-dark);
+ padding-block: calc(var(--size-4) * 1em);
+ }
+
+ [itemprop="abstract"] {
+ margin-inline: 1em;
+ padding-block: 1em;
+ font-style: italic;
+ }
+
+ @media print {
+ body {
+ font-size: 1rem;
+ font-family: var(--ff-serif);
+ line-height: 1.62;
+ }
+ }
+</style>